iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Modern Web

30 天 Rails 新手村:從工作專案學會 Ruby on Rails系列 第 11

Day 10: 授權與權限管理 - 在 Rails 中實現精細的存取控制

  • 分享至 

  • xImage
  •  

從認證到授權的關鍵一步

如果你來自 Express.js 的世界,你可能習慣了在每個路由中間件裡手動檢查權限。在 Spring Boot 中,你會使用 @PreAuthorize 註解來宣告式地控制存取。Python FastAPI 則透過 Depends 和 Security 來注入權限檢查。今天我們要探討的是 Rails 如何用完全不同的思維——將授權邏輯封裝成可測試、可重用的政策物件。

昨天我們實作了 JWT 認證系統,解決了「你是誰」的問題。今天要解決的是「你能做什麼」——這個看似簡單,實則充滿挑戰的問題。在 LMS 系統中,一個使用者可能同時是某門課的學生、另一門課的助教、還是第三門課的講師。這種動態的、基於上下文的權限管理,正是今天要深入探討的核心。

這個知識點是構建 LMS 系統的關鍵基礎。沒有精確的權限控制,我們無法確保課程資料的安全、作業提交的公平、成績查看的隱私。更重要的是,良好的授權設計能讓系統擴展變得容易——當我們需要添加新角色(如訪客旁聽、企業培訓管理員)時,不需要改動既有的業務邏輯。

授權模式的演進與選擇

Rails 授權哲學的演變

Rails 在授權這個議題上經歷了有趣的演進。早期的 Rails 應用傾向於在控制器中直接寫權限檢查:

# Rails 早期的做法 - 直接在控制器中檢查
class CoursesController < ApplicationController
  def edit
    @course = Course.find(params[:id])
    
    # 權限邏輯散落在控制器中
    unless current_user.admin? || @course.instructor == current_user
      redirect_to root_path, alert: "你沒有權限編輯此課程"
    end
  end
end

這種做法的問題顯而易見:權限邏輯與業務邏輯混雜、難以測試、容易遺漏。隨著應用規模增長,維護成本急遽上升。

現代授權模式對比

讓我們對比不同框架和 Rails 現代方案的授權策略:

框架/方案 設計理念 實作方式 優劣權衡
Express + 手動檢查 完全控制 在路由或中間件中編寫檢查邏輯 靈活但容易重複和遺漏
Spring Security 宣告式配置 使用註解和配置文件 強大但學習曲線陡峭
Django Guardian 物件級權限 資料庫儲存權限關係 精細但可能有效能問題
Rails + Pundit 政策物件模式 獨立的政策類別封裝權限邏輯 清晰、可測試、易維護
Rails + CanCanCan 能力集中定義 單一檔案定義所有權限 簡單直觀但可能過於集中

RBAC vs ABAC vs PBAC

在深入實作前,我們需要理解三種主要的授權模型:

RBAC(Role-Based Access Control)基於角色的存取控制

  • 使用者被賦予角色(如學生、講師)
  • 角色擁有權限集合
  • 適合權限相對固定的場景

ABAC(Attribute-Based Access Control)基於屬性的存取控制

  • 根據使用者、資源、環境的屬性動態判斷
  • 更靈活但更複雜
  • 適合需要細粒度控制的場景

PBAC(Policy-Based Access Control)基於政策的存取控制

  • 將授權邏輯封裝成政策
  • Rails + Pundit 採用的模式
  • 平衡了靈活性和可維護性

從零實作授權系統

第一步:設計角色模型

首先,我們為 LMS 設計一個靈活的角色系統:

# app/models/role.rb
class Role < ApplicationRecord
  # 角色是課程範圍內的,不是全域的
  belongs_to :user
  belongs_to :course
  
  # 使用 enum 定義角色類型,方便查詢和理解
  enum role_type: {
    student: 0,      # 學生:可以查看課程內容、提交作業
    teaching_assistant: 1,  # 助教:可以批改作業、管理討論區
    instructor: 2,   # 講師:完全控制課程
    observer: 3      # 旁聽:只能查看,不能參與
  }
  
  # 確保同一使用者在同一課程只有一個角色
  validates :user_id, uniqueness: { scope: :course_id }
  
  # 角色權限映射 - 定義每個角色能做什麼
  PERMISSIONS = {
    student: %i[view_content submit_assignment join_discussion],
    teaching_assistant: %i[view_content grade_assignment moderate_discussion view_analytics],
    instructor: %i[manage_course manage_content manage_users view_analytics export_data],
    observer: %i[view_content]
  }.freeze
  
  def can?(action)
    PERMISSIONS[role_type.to_sym]&.include?(action)
  end
end

# app/models/user.rb
class User < ApplicationRecord
  has_many :roles, dependent: :destroy
  has_many :courses, through: :roles
  
  # 快速檢查使用者在特定課程的角色
  def role_in_course(course)
    roles.find_by(course: course)
  end
  
  # 檢查是否為系統管理員(全域角色)
  def admin?
    admin == true
  end
  
  # 取得使用者作為講師的所有課程
  def teaching_courses
    courses.joins(:roles).where(roles: { role_type: 'instructor' })
  end
  
  # 取得使用者作為學生的所有課程
  def enrolled_courses
    courses.joins(:roles).where(roles: { role_type: 'student' })
  end
end

# app/models/course.rb
class Course < ApplicationRecord
  has_many :roles, dependent: :destroy
  has_many :users, through: :roles
  
  # 取得特定角色的使用者
  def instructors
    users.joins(:roles).where(roles: { role_type: 'instructor' })
  end
  
  def students
    users.joins(:roles).where(roles: { role_type: 'student' })
  end
  
  def teaching_assistants
    users.joins(:roles).where(roles: { role_type: 'teaching_assistant' })
  end
end

第二步:實作 Pundit 政策

現在讓我們使用 Pundit 來實作政策物件:

# Gemfile
gem 'pundit'

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include Pundit::Authorization
  
  # 全域的授權失敗處理
  rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
  
  private
  
  def user_not_authorized(exception)
    # 提供詳細的錯誤訊息幫助除錯(生產環境應該更謹慎)
    policy_name = exception.policy.class.to_s.underscore
    
    render json: {
      error: '你沒有執行此操作的權限',
      details: {
        policy: policy_name,
        action: exception.query,
        message: exception.message
      }
    }, status: :forbidden
  end
end

# app/policies/application_policy.rb
class ApplicationPolicy
  attr_reader :user, :record
  
  def initialize(user, record)
    @user = user
    @record = record
  end
  
  # 預設都是拒絕 - 安全第一
  def index?
    false
  end
  
  def show?
    false
  end
  
  def create?
    false
  end
  
  def update?
    false
  end
  
  def destroy?
    false
  end
  
  # Scope 用於過濾使用者能看到的記錄
  class Scope
    def initialize(user, scope)
      @user = user
      @scope = scope
    end
    
    def resolve
      raise NotImplementedError, "You must define #resolve in #{self.class}"
    end
    
    private
    
    attr_reader :user, :scope
  end
end

# app/policies/course_policy.rb
class CoursePolicy < ApplicationPolicy
  # 誰可以查看課程列表
  def index?
    true  # 所有登入使用者都可以瀏覽課程
  end
  
  # 誰可以查看課程詳情
  def show?
    # 公開課程所有人可看,私密課程需要是課程成員
    record.public? || user_enrolled?
  end
  
  # 誰可以創建課程
  def create?
    # 只有被授權為講師的使用者可以創建課程
    user.instructor_authorized? || user.admin?
  end
  
  # 誰可以更新課程
  def update?
    user_is_instructor? || user.admin?
  end
  
  # 誰可以刪除課程
  def destroy?
    user_is_instructor? && record.can_be_deleted? || user.admin?
  end
  
  # 自定義動作:發布課程
  def publish?
    user_is_instructor? && record.draft?
  end
  
  # 自定義動作:查看課程分析
  def view_analytics?
    user_is_instructor? || user_is_teaching_assistant?
  end
  
  # Scope:過濾使用者能看到的課程
  class Scope < ApplicationPolicy::Scope
    def resolve
      if user.admin?
        scope.all
      else
        # 使用者可以看到:公開課程 + 自己參與的課程
        scope.left_joins(:roles)
             .where('courses.public = ? OR roles.user_id = ?', true, user.id)
             .distinct
      end
    end
  end
  
  private
  
  def user_enrolled?
    record.users.exists?(user.id)
  end
  
  def user_is_instructor?
    role = user.role_in_course(record)
    role&.instructor?
  end
  
  def user_is_teaching_assistant?
    role = user.role_in_course(record)
    role&.teaching_assistant?
  end
end

第三步:處理複雜的巢狀資源權限

LMS 中的資源經常是巢狀的(課程 > 章節 > 課時),我們需要處理這種階層權限:

# app/policies/lesson_policy.rb
class LessonPolicy < ApplicationPolicy
  # 課時的權限繼承自課程
  def show?
    # 先檢查課程權限
    return false unless Pundit.policy(user, record.chapter.course).show?
    
    # 再檢查課時特定的條件
    # 例如:某些課時需要完成前置課時才能查看
    if record.has_prerequisites?
      user_completed_prerequisites?
    else
      true
    end
  end
  
  def update?
    # 只有課程講師可以編輯課時
    course = record.chapter.course
    role = user.role_in_course(course)
    role&.instructor?
  end
  
  # 學生可以標記課時為完成
  def mark_as_complete?
    course = record.chapter.course
    role = user.role_in_course(course)
    role&.student?
  end
  
  private
  
  def user_completed_prerequisites?
    record.prerequisites.all? do |prerequisite|
      user.completed_lessons.exists?(prerequisite.id)
    end
  end
end

# app/policies/assignment_policy.rb
class AssignmentPolicy < ApplicationPolicy
  def show?
    # 課程成員可以查看作業
    user_enrolled_in_course?
  end
  
  def submit?
    # 只有學生可以提交作業
    role = user.role_in_course(record.course)
    role&.student? && record.accepting_submissions?
  end
  
  def grade?
    # 講師和助教可以批改作業
    role = user.role_in_course(record.course)
    (role&.instructor? || role&.teaching_assistant?) && record.submitted?
  end
  
  # 查看提交記錄的權限依角色而定
  def view_submissions?
    role = user.role_in_course(record.course)
    
    case role&.role_type
    when 'student'
      # 學生只能看自己的提交
      false
    when 'teaching_assistant', 'instructor'
      # 助教和講師可以看所有提交
      true
    else
      false
    end
  end
  
  private
  
  def user_enrolled_in_course?
    record.course.users.exists?(user.id)
  end
end

在控制器中優雅地使用授權

# app/controllers/api/v1/courses_controller.rb
module Api
  module V1
    class CoursesController < ApplicationController
      before_action :authenticate_user!  # 來自昨天的 JWT 認證
      before_action :set_course, only: [:show, :update, :destroy, :publish, :analytics]
      
      def index
        # 使用 policy_scope 自動過濾使用者能看到的課程
        @courses = policy_scope(Course)
                    .includes(:instructors, :roles)
                    .page(params[:page])
        
        render json: @courses
      end
      
      def show
        # authorize 會自動調用 CoursePolicy#show?
        authorize @course
        
        # 根據使用者角色返回不同詳細程度的資訊
        render json: @course, 
               serializer: course_serializer_for_user,
               include: serializer_includes
      end
      
      def create
        @course = Course.new(course_params)
        
        # 檢查使用者是否有創建課程的權限
        authorize @course
        
        if @course.save
          # 自動將創建者設為講師
          @course.roles.create!(
            user: current_user,
            role_type: 'instructor'
          )
          
          render json: @course, status: :created
        else
          render json: { errors: @course.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      def update
        authorize @course
        
        if @course.update(course_params)
          render json: @course
        else
          render json: { errors: @course.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      def destroy
        authorize @course
        
        @course.destroy
        head :no_content
      end
      
      # 自定義動作:發布課程
      def publish
        authorize @course, :publish?
        
        if @course.publish!
          render json: { message: '課程已成功發布', course: @course }
        else
          render json: { errors: @course.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      # 自定義動作:查看分析
      def analytics
        authorize @course, :view_analytics?
        
        analytics_data = CourseAnalyticsService.new(@course).generate
        render json: analytics_data
      end
      
      private
      
      def set_course
        @course = Course.find(params[:id])
      end
      
      def course_params
        # 根據使用者角色允許不同的參數
        if current_user.admin?
          params.require(:course).permit!
        else
          params.require(:course).permit(
            :title, :description, :syllabus, :public,
            :start_date, :end_date, :enrollment_limit
          )
        end
      end
      
      def course_serializer_for_user
        role = current_user.role_in_course(@course)
        
        case role&.role_type
        when 'instructor'
          CourseInstructorSerializer
        when 'teaching_assistant'
          CourseTeachingAssistantSerializer
        when 'student'
          CourseStudentSerializer
        else
          CoursePublicSerializer
        end
      end
      
      def serializer_includes
        role = current_user.role_in_course(@course)
        
        case role&.role_type
        when 'instructor'
          ['chapters.lessons', 'students', 'assignments', 'analytics']
        when 'teaching_assistant'
          ['chapters.lessons', 'students', 'assignments']
        when 'student'
          ['chapters.lessons', 'assignments.user_submission']
        else
          ['chapters.lessons']
        end
      end
    end
  end
end

# app/controllers/api/v1/assignments_controller.rb  
module Api
  module V1
    class AssignmentsController < ApplicationController
      before_action :authenticate_user!
      before_action :set_course
      before_action :set_assignment, only: [:show, :submit, :submissions]
      
      def show
        authorize @assignment
        
        # 學生看到自己的提交狀態,講師看到統計資訊
        render json: @assignment, 
               serializer: assignment_serializer_for_user
      end
      
      def submit
        authorize @assignment, :submit?
        
        submission = @assignment.submissions.build(
          user: current_user,
          content: params[:content],
          submitted_at: Time.current
        )
        
        if submission.save
          # 觸發自動批改(如果是選擇題)
          AutoGradeJob.perform_later(submission) if @assignment.auto_gradable?
          
          render json: submission, status: :created
        else
          render json: { errors: submission.errors.full_messages },
                 status: :unprocessable_entity
        end
      end
      
      def submissions
        # 檢查是否有查看所有提交的權限
        authorize @assignment, :view_submissions?
        
        @submissions = @assignment.submissions
                                  .includes(:user, :grades)
                                  .page(params[:page])
        
        render json: @submissions
      end
      
      private
      
      def set_course
        @course = Course.find(params[:course_id])
      end
      
      def set_assignment
        @assignment = @course.assignments.find(params[:id])
      end
      
      def assignment_serializer_for_user
        role = current_user.role_in_course(@course)
        
        case role&.role_type
        when 'instructor', 'teaching_assistant'
          AssignmentInstructorSerializer
        when 'student'
          AssignmentStudentSerializer
        else
          AssignmentPublicSerializer
        end
      end
    end
  end
end

處理動態權限和特殊情況

實作基於時間的權限

# app/policies/exam_policy.rb
class ExamPolicy < ApplicationPolicy
  def take?
    # 學生只能在考試時間內參加考試
    return false unless user_is_student?
    
    # 檢查時間窗口
    now = Time.current
    return false unless now.between?(record.start_time, record.end_time)
    
    # 檢查是否已經參加過
    return false if user.exam_attempts.exists?(exam: record)
    
    # 檢查是否有特殊安排(如延長時間)
    special_arrangement = record.special_arrangements.find_by(user: user)
    if special_arrangement
      return now.between?(
        special_arrangement.start_time,
        special_arrangement.end_time
      )
    end
    
    true
  end
  
  def review?
    # 考試結束後才能查看
    return false unless Time.current > record.end_time
    
    # 學生只能查看自己的答案
    if user_is_student?
      user.exam_attempts.exists?(exam: record)
    else
      # 講師和助教可以查看所有答案
      user_is_instructor? || user_is_teaching_assistant?
    end
  end
  
  private
  
  def user_is_student?
    role = user.role_in_course(record.course)
    role&.student?
  end
  
  def user_is_instructor?
    role = user.role_in_course(record.course)
    role&.instructor?
  end
  
  def user_is_teaching_assistant?
    role = user.role_in_course(record.course)
    role&.teaching_assistant?
  end
end

實作委派權限

# app/models/permission_delegation.rb
class PermissionDelegation < ApplicationRecord
  belongs_to :delegator, class_name: 'User'
  belongs_to :delegate, class_name: 'User'
  belongs_to :course
  
  # 使用 JSONB 儲存委派的具體權限
  # permissions: ['grade_assignments', 'moderate_discussions']
  
  validates :delegator_id, uniqueness: { 
    scope: [:delegate_id, :course_id] 
  }
  validate :delegator_must_be_instructor
  validate :expiry_date_in_future
  
  scope :active, -> { where('expiry_date > ?', Time.current) }
  
  private
  
  def delegator_must_be_instructor
    unless delegator.role_in_course(course)&.instructor?
      errors.add(:delegator, '必須是課程講師才能委派權限')
    end
  end
  
  def expiry_date_in_future
    if expiry_date.present? && expiry_date <= Time.current
      errors.add(:expiry_date, '必須是未來的日期')
    end
  end
end

# 修改政策以支援委派權限
class CoursePolicy < ApplicationPolicy
  def grade_assignments?
    # 原本的權限檢查
    return true if user_is_instructor? || user_is_teaching_assistant?
    
    # 檢查是否有委派權限
    has_delegated_permission?('grade_assignments')
  end
  
  private
  
  def has_delegated_permission?(permission)
    PermissionDelegation
      .active
      .where(delegate: user, course: record)
      .where('permissions @> ?', [permission].to_json)
      .exists?
  end
end

效能優化與快取策略

使用快取避免重複查詢

# app/models/concerns/cacheable_permissions.rb
module CacheablePermissions
  extend ActiveSupport::Concern
  
  included do
    # 當角色變更時清除快取
    after_save :clear_permission_cache
    after_destroy :clear_permission_cache
  end
  
  def can_perform?(action, resource)
    cache_key = "permissions/#{user_id}/#{resource.class.name}/#{resource.id}/#{action}"
    
    Rails.cache.fetch(cache_key, expires_in: 1.hour) do
      policy = Pundit.policy(user, resource)
      policy.public_send("#{action}?")
    end
  end
  
  private
  
  def clear_permission_cache
    # 清除相關的權限快取
    Rails.cache.delete_matched("permissions/#{user_id}/*")
  end
end

# app/controllers/concerns/efficient_authorization.rb
module EfficientAuthorization
  extend ActiveSupport::Concern
  
  included do
    # 批量預載入權限檢查所需的關聯
    def preload_authorization_data
      return unless current_user
      
      # 預載入使用者的所有角色
      current_user.roles.includes(:course).load
      
      # 預載入活躍的權限委派
      current_user.received_delegations
                  .active
                  .includes(:course)
                  .load
    end
  end
  
  # 批量授權檢查
  def authorize_collection(records, action = nil)
    action ||= "#{action_name}?"
    
    records.select do |record|
      policy = Pundit.policy(current_user, record)
      policy.public_send(action)
    end
  end
end

使用資料庫層級的權限過濾

# app/models/course.rb
class Course < ApplicationRecord
  # 使用 scope 在資料庫層級過濾
  scope :visible_to, ->(user) {
    if user.admin?
      all
    else
      left_joins(:roles)
        .where(
          'courses.public = :is_public OR roles.user_id = :user_id',
          is_public: true,
          user_id: user.id
        )
        .distinct
    end
  }
  
  scope :manageable_by, ->(user) {
    if user.admin?
      all
    else
      joins(:roles)
        .where(
          roles: { 
            user_id: user.id, 
            role_type: ['instructor', 'teaching_assistant'] 
          }
        )
        .distinct
    end
  }
end

# 在控制器中使用
class CoursesController < ApplicationController
  def index
    # 直接在資料庫層級過濾,避免 N+1 查詢
    @courses = Course.visible_to(current_user)
                     .includes(:instructors, :categories)
                     .page(params[:page])
  end
  
  def manageable
    @courses = Course.manageable_by(current_user)
                     .includes(:students, :assignments)
                     .page(params[:page])
  end
end

測試授權邏輯

# spec/policies/course_policy_spec.rb
require 'rails_helper'

RSpec.describe CoursePolicy do
  subject { described_class.new(user, course) }
  
  let(:course) { create(:course) }
  
  context '當使用者是學生時' do
    let(:user) { create(:user) }
    let!(:role) { create(:role, user: user, course: course, role_type: 'student') }
    
    it { is_expected.to permit_action(:show) }
    it { is_expected.to forbid_action(:update) }
    it { is_expected.to forbid_action(:destroy) }
    it { is_expected.to forbid_action(:publish) }
    it { is_expected.to forbid_action(:view_analytics) }
  end
  
  context '當使用者是助教時' do
    let(:user) { create(:user) }
    let!(:role) { create(:role, user: user, course: course, role_type: 'teaching_assistant') }
    
    it { is_expected.to permit_action(:show) }
    it { is_expected.to forbid_action(:update) }
    it { is_expected.to forbid_action(:destroy) }
    it { is_expected.to permit_action(:view_analytics) }
  end
  
  context '當使用者是講師時' do
    let(:user) { create(:user) }
    let!(:role) { create(:role, user: user, course: course, role_type: 'instructor') }
    
    it { is_expected.to permit_actions([:show, :update, :view_analytics]) }
    
    context '當課程可以刪除時' do
      before { allow(course).to receive(:can_be_deleted?).and_return(true) }
      it { is_expected.to permit_action(:destroy) }
    end
    
    context '當課程是草稿狀態時' do
      before { allow(course).to receive(:draft?).and_return(true) }
      it { is_expected.to permit_action(:publish) }
    end
  end
  
  describe '範圍過濾' do
    let(:user) { create(:user) }
    let!(:public_course) { create(:course, public: true) }
    let!(:private_course) { create(:course, public: false) }
    let!(:enrolled_course) { create(:course, public: false) }
    let!(:enrollment) { create(:role, user: user, course: enrolled_course) }
    
    it '返回使用者可見的課程' do
      scope = CoursePolicy::Scope.new(user, Course).resolve
      
      expect(scope).to include(public_course, enrolled_course)
      expect(scope).not_to include(private_course)
    end
  end
end

# spec/requests/api/v1/courses_spec.rb
require 'rails_helper'

RSpec.describe 'Courses API', type: :request do
  let(:instructor) { create(:user) }
  let(:student) { create(:user) }
  let(:course) { create(:course) }
  
  before do
    create(:role, user: instructor, course: course, role_type: 'instructor')
    create(:role, user: student, course: course, role_type: 'student')
  end
  
  describe 'PUT /api/v1/courses/:id' do
    context '當使用者是講師時' do
      before { sign_in(instructor) }  # 假設有 sign_in helper
      
      it '允許更新課程' do
        put "/api/v1/courses/#{course.id}", 
            params: { course: { title: '新標題' } }
        
        expect(response).to have_http_status(:ok)
        expect(course.reload.title).to eq('新標題')
      end
    end
    
    context '當使用者是學生時' do
      before { sign_in(student) }
      
      it '拒絕更新課程' do
        put "/api/v1/courses/#{course.id}", 
            params: { course: { title: '新標題' } }
        
        expect(response).to have_http_status(:forbidden)
        expect(response.body).to include('沒有執行此操作的權限')
      end
    end
  end
end

實踐練習:動手鞏固

基礎練習:討論區權限系統(預計 30 分鐘)

練習目標: 實作一個課程討論區的完整權限系統

讓我們一起建立一個真實的討論區系統。這個練習會幫助你理解如何在實際場景中應用今天學到的授權概念。討論區是 LMS 中最複雜的權限場景之一,因為它涉及多種角色、動態內容和隱私考量。

需求詳解:

討論區需要支援以下功能,每個功能都有特定的權限要求:

  • 學生可以發布討論主題和回覆他人
  • 學生可以編輯自己的文章(30 分鐘內)
  • 助教可以置頂重要討論和刪除不當內容
  • 講師擁有完全的管理權限
  • 支援匿名發文(但講師和助教能看到真實身份)
  • 實作文章鎖定功能(鎖定後只能查看不能回覆)

解答與實作指南:

首先,我們建立資料模型。注意這裡的設計決策:我們使用 anonymous 欄位來標記匿名文章,而不是清空作者資訊,這樣可以保留追溯能力:

# app/models/discussion_post.rb
class DiscussionPost < ApplicationRecord
  belongs_to :course
  belongs_to :author, class_name: 'User'
  belongs_to :parent, class_name: 'DiscussionPost', optional: true
  has_many :replies, class_name: 'DiscussionPost', foreign_key: 'parent_id'
  
  # 使用 enum 管理文章狀態,這比多個布林欄位更清晰
  enum status: {
    active: 0,
    locked: 1,      # 鎖定:可看不可回
    hidden: 2,      # 隱藏:只有作者和管理員可見
    deleted: 3      # 軟刪除
  }
  
  # 這些 scope 讓我們能輕鬆過濾不同狀態的文章
  scope :visible, -> { where.not(status: [:hidden, :deleted]) }
  scope :pinned_first, -> { order(pinned: :desc, created_at: :desc) }
  
  # 判斷是否還在可編輯時間窗口內
  def editable_by_author?
    created_at > 30.minutes.ago && active?
  end
  
  # 匿名顯示的作者名稱
  def display_author_name(viewer)
    return author.name if !anonymous?
    
    # 只有講師和助教能看穿匿名
    role = viewer.role_in_course(course)
    if role&.instructor? || role&.teaching_assistant?
      "#{author.name} (匿名發文)"
    else
      "匿名同學 ##{anonymous_identifier}"
    end
  end
  
  private
  
  # 為每個匿名文章生成一個會話內的固定標識
  def anonymous_identifier
    # 使用文章 ID 的雜湊值生成一個看起來隨機但固定的數字
    Digest::SHA256.hexdigest("#{id}-#{course_id}")[0..5].to_i(16) % 10000
  end
end

接下來是關鍵的政策物件。注意我們如何處理不同角色的權限差異,以及時間相關的權限邏輯:

# app/policies/discussion_post_policy.rb
class DiscussionPostPolicy < ApplicationPolicy
  # 查看權限:課程成員都可以看,但要過濾隱藏的內容
  def show?
    return false unless user_enrolled_in_course?
    
    # 隱藏的文章只有作者和管理員能看
    if record.hidden?
      is_author? || is_course_staff?
    else
      true
    end
  end
  
  # 創建權限:所有課程成員都可以發文
  def create?
    user_enrolled_in_course? && course_allows_discussion?
  end
  
  # 更新權限:這裡的邏輯最複雜
  def update?
    return false unless user_enrolled_in_course?
    
    # 管理員隨時可以編輯
    return true if is_instructor?
    
    # 助教可以編輯元資料(置頂、鎖定)但不能改內容
    return true if is_teaching_assistant? && !content_changed?
    
    # 作者只能在時間窗口內編輯自己的文章
    is_author? && record.editable_by_author?
  end
  
  # 刪除權限:注意這是軟刪除
  def destroy?
    # 講師可以刪除任何文章
    return true if is_instructor?
    
    # 助教可以刪除違規內容
    return true if is_teaching_assistant?
    
    # 作者可以刪除自己的文章(時間窗口內)
    is_author? && record.editable_by_author?
  end
  
  # 置頂權限:只有課程管理員
  def pin?
    is_instructor? || is_teaching_assistant?
  end
  
  # 鎖定權限:防止進一步回覆
  def lock?
    is_instructor? || is_teaching_assistant?
  end
  
  # 查看真實作者身份
  def reveal_author?
    is_instructor? || is_teaching_assistant?
  end
  
  # 回覆權限:要考慮文章是否被鎖定
  def reply?
    return false unless user_enrolled_in_course?
    return false if record.locked?
    
    # 課程可能暫時關閉討論功能
    course_allows_discussion?
  end
  
  # Scope:根據角色過濾可見的文章
  class Scope < ApplicationPolicy::Scope
    def resolve
      role = user.role_in_course(scope.first&.course)
      
      if role&.instructor? || role&.teaching_assistant?
        # 管理員看到所有文章,包括隱藏的
        scope.all
      elsif role
        # 一般成員看到公開文章和自己的文章
        scope.where(status: [:active, :locked])
             .or(scope.where(author: user))
      else
        # 非課程成員看不到任何文章
        scope.none
      end
    end
  end
  
  private
  
  def user_enrolled_in_course?
    @enrolled ||= record.course.users.exists?(user.id)
  end
  
  def is_author?
    record.author_id == user.id
  end
  
  def is_instructor?
    @role ||= user.role_in_course(record.course)
    @role&.instructor?
  end
  
  def is_teaching_assistant?
    @role ||= user.role_in_course(record.course)
    @role&.teaching_assistant?
  end
  
  def is_course_staff?
    is_instructor? || is_teaching_assistant?
  end
  
  def course_allows_discussion?
    # 課程可能暫時關閉討論功能(如考試期間)
    record.course.discussion_enabled?
  end
  
  def content_changed?
    # 檢查是否嘗試修改文章內容(而非只是元資料)
    return false unless record.changed?
    
    (record.changed_attributes.keys & ['title', 'content', 'anonymous']).any?
  end
end

現在實作控制器,注意我們如何優雅地處理不同的權限場景:

# app/controllers/api/v1/discussion_posts_controller.rb
module Api
  module V1
    class DiscussionPostsController < ApplicationController
      before_action :authenticate_user!
      before_action :set_course
      before_action :set_post, only: [:show, :update, :destroy, :pin, :lock, :reply]
      
      def index
        # 使用 policy_scope 自動過濾可見文章
        @posts = policy_scope(@course.discussion_posts)
                   .includes(:author, :replies)
                   .pinned_first
                   .page(params[:page])
        
        # 根據權限決定是否顯示作者真實身份
        render json: @posts, 
               each_serializer: DiscussionPostSerializer,
               current_user: current_user
      end
      
      def create
        @post = @course.discussion_posts.build(post_params)
        @post.author = current_user
        
        authorize @post
        
        if @post.save
          # 如果是回覆,通知原作者
          notify_author_of_reply if @post.parent_id.present?
          
          render json: @post, 
                 serializer: DiscussionPostSerializer,
                 current_user: current_user,
                 status: :created
        else
          render json: { errors: @post.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      def update
        authorize @post
        
        # 助教只能更新元資料
        permitted_params = if policy(@post).is_course_staff? && !policy(@post).is_instructor?
          post_metadata_params
        else
          post_params
        end
        
        if @post.update(permitted_params)
          render json: @post, 
                 serializer: DiscussionPostSerializer,
                 current_user: current_user
        else
          render json: { errors: @post.errors.full_messages }, 
                 status: :unprocessable_entity
        end
      end
      
      def destroy
        authorize @post
        
        # 軟刪除而非真的刪除,保留審計記錄
        @post.update!(
          status: 'deleted',
          deleted_by: current_user,
          deleted_at: Time.current
        )
        
        render json: { message: '文章已刪除' }
      end
      
      def pin
        authorize @post, :pin?
        
        # 同一時間只能有有限數量的置頂文章
        if @post.pinned?
          @post.update!(pinned: false)
          message = '已取消置頂'
        else
          # 如果超過置頂上限,取消最舊的置頂
          if @course.discussion_posts.where(pinned: true).count >= 3
            oldest_pinned = @course.discussion_posts
                                   .where(pinned: true)
                                   .order(:updated_at)
                                   .first
            oldest_pinned.update!(pinned: false)
          end
          
          @post.update!(pinned: true)
          message = '文章已置頂'
        end
        
        render json: { message: message, post: @post }
      end
      
      def lock
        authorize @post, :lock?
        
        @post.update!(status: @post.locked? ? 'active' : 'locked')
        
        message = @post.locked? ? '文章已鎖定' : '文章已解鎖'
        render json: { message: message, post: @post }
      end
      
      private
      
      def set_course
        @course = Course.find(params[:course_id])
      end
      
      def set_post
        @post = @course.discussion_posts.find(params[:id])
      end
      
      def post_params
        params.require(:discussion_post).permit(
          :title, :content, :anonymous, :parent_id
        )
      end
      
      def post_metadata_params
        params.require(:discussion_post).permit(:pinned, :status)
      end
      
      def notify_author_of_reply
        return if @post.parent.author == current_user
        
        DiscussionReplyNotificationJob.perform_later(
          @post.parent.author,
          @post
        )
      end
    end
  end
end

# app/serializers/discussion_post_serializer.rb
class DiscussionPostSerializer < ActiveModel::Serializer
  attributes :id, :title, :content, :created_at, :updated_at,
             :pinned, :status, :replies_count, :author_name,
             :editable, :deletable, :can_pin, :can_lock
  
  def author_name
    # 使用模型方法處理匿名邏輯
    object.display_author_name(current_user)
  end
  
  def editable
    # 告訴前端這篇文章是否可編輯
    Pundit.policy(current_user, object).update?
  end
  
  def deletable
    Pundit.policy(current_user, object).destroy?
  end
  
  def can_pin
    Pundit.policy(current_user, object).pin?
  end
  
  def can_lock
    Pundit.policy(current_user, object).lock?
  end
  
  def replies_count
    object.replies.visible.count
  end
end

進階挑戰:小組作業權限系統(預計 1 小時)

挑戰目標: 實作一個支援協作的小組作業系統

小組作業是 LMS 中最複雜的功能之一,因為它涉及動態的群組成員關係、階層式的權限(組長 vs 組員)、以及時間相關的狀態變化。讓我們一起解決這個挑戰。

需求詳解:

這個系統需要處理以下複雜場景:

  • 學生可以自由組隊(在允許的時間內)或由講師分配
  • 每組有人數上下限(如 3-5 人)
  • 小組成員可以互相查看和編輯作業內容
  • 組長負責最終提交,且提交後作業被鎖定
  • 處理成員退出和轉組的情況
  • 助教可以監控所有小組的進度
  • 支援草稿自動儲存和版本歷史

解答與實作指南:

首先建立資料模型。注意我們如何處理小組成員的動態關係:

# app/models/group_assignment.rb
class GroupAssignment < ApplicationRecord
  belongs_to :course
  belongs_to :assignment
  has_many :groups, dependent: :destroy
  
  # 配置小組限制
  validates :min_members, :max_members, presence: true
  validates :min_members, numericality: { greater_than: 0 }
  validates :max_members, numericality: { greater_than_or_equal_to: :min_members }
  
  enum formation_method: {
    self_organized: 0,  # 學生自由組隊
    instructor_assigned: 1,  # 講師分配
    hybrid: 2  # 混合模式:學生先自由組隊,講師補充分配
  }
  
  # 組隊階段控制
  def formation_period_active?
    return false if formation_deadline.blank?
    Time.current <= formation_deadline
  end
  
  # 是否允許學生自行組隊
  def allows_self_organization?
    self_organized? || hybrid?
  end
end

# app/models/group.rb
class Group < ApplicationRecord
  belongs_to :group_assignment
  has_many :group_memberships, dependent: :destroy
  has_many :members, through: :group_memberships, source: :user
  has_one :group_submission
  has_many :group_work_versions, dependent: :destroy
  
  # 使用 Redis 儲存編輯鎖,避免同時編輯衝突
  def editing_locked_by
    Rails.cache.read("group_edit_lock_#{id}")
  end
  
  def lock_for_editing(user, duration = 5.minutes)
    Rails.cache.write(
      "group_edit_lock_#{id}", 
      user.id, 
      expires_in: duration
    )
  end
  
  def unlock_editing
    Rails.cache.delete("group_edit_lock_#{id}")
  end
  
  # 檢查小組是否符合人數要求
  def valid_size?
    size = members.count
    size >= group_assignment.min_members && 
    size <= group_assignment.max_members
  end
  
  # 小組是否已提交作業
  def submitted?
    group_submission.present? && group_submission.submitted?
  end
  
  # 取得目前的組長
  def leader
    leader_membership = group_memberships.find_by(role: 'leader')
    leader_membership&.user
  end
  
  # 自動儲存版本
  def create_version!(content, edited_by)
    group_work_versions.create!(
      content: content,
      edited_by: edited_by,
      version_number: next_version_number
    )
  end
  
  private
  
  def next_version_number
    (group_work_versions.maximum(:version_number) || 0) + 1
  end
end

# app/models/group_membership.rb
class GroupMembership < ApplicationRecord
  belongs_to :group
  belongs_to :user
  
  enum role: {
    member: 0,
    leader: 1
  }
  
  enum status: {
    active: 0,
    left: 1,      # 主動離開
    removed: 2    # 被移除
  }
  
  # 確保每組只有一個組長
  validate :only_one_leader_per_group, if: :leader?
  
  # 記錄加入和離開時間
  scope :current, -> { where(status: 'active') }
  scope :past, -> { where(status: ['left', 'removed']) }
  
  # 記錄成員變更歷史
  after_update :log_membership_change, if: :saved_change_to_status?
  
  private
  
  def only_one_leader_per_group
    existing_leader = group.group_memberships
                           .where(role: 'leader', status: 'active')
                           .where.not(id: id)
                           .exists?
    
    errors.add(:role, '每組只能有一個組長') if existing_leader
  end
  
  def log_membership_change
    GroupMembershipLog.create!(
      group: group,
      user: user,
      action: status,
      performed_by: Current.user,  # 需要設置 Current.user
      performed_at: Time.current
    )
  end
end

實作複雜的小組權限政策:

# app/policies/group_policy.rb
class GroupPolicy < ApplicationPolicy
  # 查看小組資訊
  def show?
    # 小組成員、講師、助教可以查看
    is_member? || is_course_staff?
  end
  
  # 創建小組(學生自由組隊)
  def create?
    return false unless group_assignment.allows_self_organization?
    return false unless group_assignment.formation_period_active?
    
    # 學生且尚未加入其他小組
    is_student? && !already_in_group?
  end
  
  # 加入小組
  def join?
    return false unless group_assignment.allows_self_organization?
    return false unless group_assignment.formation_period_active?
    return false if record.members.count >= group_assignment.max_members
    
    is_student? && !already_in_group?
  end
  
  # 離開小組
  def leave?
    return false unless group_assignment.formation_period_active?
    return false if record.submitted?  # 提交後不能離開
    
    # 組長不能離開,除非先轉讓組長身份
    return false if is_leader? && record.members.current.count > 1
    
    is_member?
  end
  
  # 編輯作業內容
  def edit_content?
    return false if record.submitted?  # 提交後鎖定
    return false if record.editing_locked_by && 
                    record.editing_locked_by != user.id
    
    is_active_member?
  end
  
  # 提交作業(只有組長)
  def submit?
    return false if record.submitted?
    return false unless record.valid_size?
    return false if past_deadline?
    
    is_leader?
  end
  
  # 轉讓組長
  def transfer_leadership?
    is_leader? && !record.submitted?
  end
  
  # 移除成員(組長權限)
  def remove_member?
    return false if record.submitted?
    
    is_leader? || is_instructor?
  end
  
  # 查看所有小組(助教和講師)
  def view_all_groups?
    is_course_staff?
  end
  
  # 強制分配成員(講師權限)
  def assign_members?
    is_instructor? && group_assignment.formation_period_active?
  end
  
  private
  
  def group_assignment
    @group_assignment ||= record.group_assignment
  end
  
  def is_member?
    record.group_memberships.current.exists?(user: user)
  end
  
  def is_active_member?
    record.group_memberships.active.exists?(user: user)
  end
  
  def is_leader?
    record.group_memberships.active.exists?(user: user, role: 'leader')
  end
  
  def already_in_group?
    group_assignment.groups
                    .joins(:group_memberships)
                    .where(group_memberships: { user: user, status: 'active' })
                    .exists?
  end
  
  def is_student?
    role = user.role_in_course(group_assignment.course)
    role&.student?
  end
  
  def is_instructor?
    role = user.role_in_course(group_assignment.course)
    role&.instructor?
  end
  
  def is_course_staff?
    role = user.role_in_course(group_assignment.course)
    role&.instructor? || role&.teaching_assistant?
  end
  
  def past_deadline?
    group_assignment.assignment.deadline < Time.current
  end
end

# app/policies/group_work_version_policy.rb
class GroupWorkVersionPolicy < ApplicationPolicy
  # 查看版本歷史
  def index?
    # 小組成員和課程管理員可以查看版本歷史
    group = record.first&.group
    return false unless group
    
    GroupPolicy.new(user, group).show?
  end
  
  # 回復到特定版本
  def restore?
    group = record.group
    return false if group.submitted?
    
    # 只有小組成員可以回復版本
    group.members.current.exists?(user.id)
  end
  
  # 查看版本差異
  def diff?
    GroupPolicy.new(user, record.group).show?
  end
end

控制器實作,處理各種小組操作:

# app/controllers/api/v1/groups_controller.rb
module Api
  module V1
    class GroupsController < ApplicationController
      before_action :authenticate_user!
      before_action :set_group_assignment
      before_action :set_group, only: [:show, :join, :leave, :edit_content, 
                                       :submit, :transfer_leadership, :remove_member]
      
      def index
        if policy(@group_assignment).view_all_groups?
          # 管理員看到所有小組
          @groups = @group_assignment.groups
                                     .includes(:members, :group_submission)
        else
          # 學生只看到自己的小組
          @groups = @group_assignment.groups
                                     .joins(:group_memberships)
                                     .where(group_memberships: { 
                                       user: current_user, 
                                       status: 'active' 
                                     })
        end
        
        render json: @groups, each_serializer: GroupSerializer
      end
      
      def create
        @group = @group_assignment.groups.build(group_params)
        authorize @group
        
        ActiveRecord::Base.transaction do
          @group.save!
          
          # 創建者自動成為組長
          @group.group_memberships.create!(
            user: current_user,
            role: 'leader',
            status: 'active'
          )
          
          # 如果有邀請成員,發送邀請
          if params[:invited_user_ids].present?
            send_invitations(params[:invited_user_ids])
          end
        end
        
        render json: @group, status: :created
      rescue ActiveRecord::RecordInvalid => e
        render json: { errors: e.record.errors.full_messages }, 
               status: :unprocessable_entity
      end
      
      def join
        authorize @group
        
        ActiveRecord::Base.transaction do
          membership = @group.group_memberships.create!(
            user: current_user,
            role: 'member',
            status: 'active'
          )
          
          # 通知其他成員
          notify_members_of_new_member
        end
        
        render json: @group
      rescue ActiveRecord::RecordInvalid => e
        render json: { errors: e.record.errors.full_messages }, 
               status: :unprocessable_entity
      end
      
      def leave
        authorize @group
        
        membership = @group.group_memberships.find_by(user: current_user)
        
        ActiveRecord::Base.transaction do
          # 如果是最後一個成員,解散小組
          if @group.members.current.count == 1
            @group.destroy!
            render json: { message: '小組已解散' }
          else
            membership.update!(status: 'left', left_at: Time.current)
            
            # 如果是組長離開,自動轉讓給最早加入的成員
            if membership.leader?
              new_leader = @group.group_memberships
                                 .active
                                 .where.not(id: membership.id)
                                 .order(:created_at)
                                 .first
              new_leader.update!(role: 'leader')
            end
            
            render json: { message: '已離開小組' }
          end
        end
      end
      
      def edit_content
        authorize @group
        
        # 實作編輯鎖機制,避免同時編輯
        if @group.editing_locked_by && @group.editing_locked_by != current_user.id
          locker = User.find(@group.editing_locked_by)
          render json: { 
            error: "#{locker.name} 正在編輯,請稍後再試" 
          }, status: :conflict
          return
        end
        
        # 取得編輯鎖
        @group.lock_for_editing(current_user)
        
        # 自動儲存為新版本
        version = @group.create_version!(
          params[:content],
          current_user
        )
        
        # 更新當前內容
        @group.update!(current_content: params[:content])
        
        # 釋放編輯鎖
        @group.unlock_editing
        
        render json: {
          message: '內容已儲存',
          version: version,
          group: @group
        }
      end
      
      def submit
        authorize @group
        
        ActiveRecord::Base.transaction do
          # 創建提交記錄
          submission = @group.create_group_submission!(
            submitted_by: current_user,
            submitted_at: Time.current,
            content: @group.current_content,
            members_snapshot: @group.members.current.pluck(:id, :name)
          )
          
          # 鎖定小組,防止進一步修改
          @group.update!(locked: true)
          
          # 通知所有成員
          notify_submission
          
          render json: {
            message: '作業已提交',
            submission: submission
          }
        end
      rescue => e
        render json: { error: e.message }, status: :unprocessable_entity
      end
      
      def transfer_leadership
        authorize @group
        
        new_leader = @group.members.find(params[:new_leader_id])
        
        ActiveRecord::Base.transaction do
          # 移除舊組長角色
          @group.group_memberships.find_by(role: 'leader')
                .update!(role: 'member')
          
          # 設置新組長
          @group.group_memberships.find_by(user: new_leader)
                .update!(role: 'leader')
        end
        
        render json: { 
          message: "組長已轉讓給 #{new_leader.name}",
          group: @group
        }
      end
      
      private
      
      def set_group_assignment
        @group_assignment = GroupAssignment.find(params[:group_assignment_id])
      end
      
      def set_group
        @group = @group_assignment.groups.find(params[:id])
      end
      
      def group_params
        params.require(:group).permit(:name, :description)
      end
      
      def send_invitations(user_ids)
        users = User.where(id: user_ids)
        users.each do |user|
          GroupInvitationMailer.invite(user, @group, current_user).deliver_later
        end
      end
      
      def notify_members_of_new_member
        @group.members.where.not(id: current_user.id).each do |member|
          GroupNotificationJob.perform_later(
            member, 
            "#{current_user.name} 加入了你的小組"
          )
        end
      end
      
      def notify_submission
        @group.members.each do |member|
          SubmissionNotificationJob.perform_later(
            member,
            @group,
            current_user
          )
        end
      end
    end
  end
end

關鍵決策點解析:

在實作這個系統時,我們做了幾個重要的設計決策。首先是編輯鎖機制,這解決了多人同時編輯的衝突問題。我們使用 Redis 而非資料庫來儲存鎖定狀態,因為鎖是暫時的,且需要自動過期功能。

其次是版本控制系統,每次編輯都會創建新版本,這不僅提供了歷史追蹤,也讓我們能在出現問題時回復到之前的版本。這對小組作業特別重要,因為一個成員的錯誤修改可能影響整個小組。

最後是成員變更的處理,我們選擇軟刪除(將狀態改為 'left' 或 'removed')而非真的刪除記錄,這樣可以保留完整的歷史記錄,對於成績爭議的處理非常重要。

測試要點:

# spec/policies/group_policy_spec.rb
RSpec.describe GroupPolicy do
  let(:group_assignment) { create(:group_assignment) }
  let(:group) { create(:group, group_assignment: group_assignment) }
  
  describe '小組編輯權限' do
    context '當多人同時編輯時' do
      let(:member1) { create(:user) }
      let(:member2) { create(:user) }
      
      before do
        group.members << [member1, member2]
        group.lock_for_editing(member1)
      end
      
      it '第二個成員無法編輯' do
        policy = described_class.new(member2, group)
        expect(policy.edit_content?).to be_falsey
      end
      
      it '鎖定過期後可以編輯' do
        travel_to 6.minutes.from_now do
          policy = described_class.new(member2, group)
          expect(policy.edit_content?).to be_truthy
        end
      end
    end
  end
end

這兩個練習涵蓋了授權系統的核心概念,從簡單的角色權限到複雜的上下文相關權限。透過實作這些系統,你應該能深刻理解如何在 Rails 中設計和實作精細的權限控制。記住,好的授權系統不只是能工作,更要易於理解、測試和維護。

知識連結

與前期內容的連結

  • Day 9 的認證系統:今天的授權建立在昨天的認證基礎上,current_user 是連接兩者的橋樑
  • Day 4 的 ActiveRecord:角色模型展示了如何用關聯表達複雜的業務關係
  • Day 6 的控制器模式:授權檢查完美體現了 "Skinny Controller" 原則

對後續內容的鋪墊

  • Day 13 的測試驅動開發:授權邏輯特別適合 TDD,因為規則明確且容易驗證
  • Day 14 的背景任務:某些授權變更(如角色過期)需要背景任務處理
  • Day 22-23 的 LMS 核心功能:今天建立的權限系統是整個 LMS 的安全基石

總結與展望

核心收穫

知識層面:

  • 學會了使用 Pundit 實作政策物件模式
  • 理解了 RBAC、ABAC、PBAC 的差異與適用場景
  • 掌握了處理巢狀資源和動態權限的技巧

思維層面:

  • 理解了將授權邏輯獨立封裝的價值
  • 體會了「預設拒絕」的安全設計原則
  • 認識到好的授權設計能提升系統的可維護性

實踐層面:

  • 能夠設計和實作多角色的權限系統
  • 能夠處理複雜的上下文相關權限
  • 能夠為授權邏輯編寫完整的測試

自我檢核清單

完成今天的學習後,你應該能夠:

  • [ ] 解釋 Pundit 的政策物件模式與其他框架授權方式的差異
  • [ ] 實作基於角色和上下文的複雜權限系統
  • [ ] 識別並避免常見的授權漏洞(如忘記檢查巢狀資源)
  • [ ] 在 LMS 專案中設計合理的角色和權限架構

延伸資源

深入閱讀:

相關 Gem:

  • action_policy - 更強大但更複雜的授權方案
  • rolify - 角色管理的輔助工具
  • jwt - 配合昨天的 JWT 認證使用

明日預告

明天我們將探討 API 版本控制與向後相容。如果說今天學習的是保護 API 的安全,那明天就是確保 API 的永續發展。當你的 LMS API 被眾多前端應用依賴時,如何優雅地演進而不破壞既有功能?準備好了嗎?讓我們繼續這段旅程。


上一篇
Day 9: 認證系統實作 - 從零打造 JWT 認證的完整旅程
下一篇
Day 11: API 版本控制與向後相容 - 優雅演進的藝術
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言